모노레포 배포 분리하기 재도전기

2026.03.05

[[2. Area/(W) 헬퍼쿡&헬퍼프라이 - 유지보수/모노레포 버전 도입 재도전기|모노레포 버전 도입 재도전기]]를 진행하다가, "버전 도입"을 버리기로 하고, 배포 분리만 살림.
결과적으로 "버전 관리"를 버리니, "changeset"을 같이 사용할 이유가 없어졌다.

  1. 버전 관리는 이번 목표에서 스킵. 여기서 버전 관리까지 하려니까 너무 꼬임.
  2. changeset을 활용하는 목적은 "변경 사항 감지"에 그칠 것. 근데 이거 turborepo 자체로 확인하는 법 없나?
    1. 있다고 함
    2. turborepo는 현재 브랜치와 비교 대상(예: main 또는 이전 커밋)을 비교하여 영향을 받는 프로젝트만 필터링할 수 있음.
# develop 브랜치와 비교해서 변경한 것과, 그것에 의존하는 앱만 빌드
pnpm turbo build --filter=...[origin/develop]
  • ...[origin/develop]: "origin/develop 브랜치 이후로 변경된 모든 것과 그 변경사항에 의존하는 하위 프로젝트들"을 의미합니다.
    • 이 명령어를 실행하면, 변경되지 않은 앱은 자동으로 >>> FULL TURBO (캐시 히트) 가 뜨거나 실행 대상에서 제외됩니다.
    1. 그렇담 이제 changeset을 유지해야 할 이유가 없음. changeset은 업데이트를 해야 할 패키지를 명시적으로 선택할 수 있는 부분에서 낫지만, 버전 관리를 하지 않는다면 이 기능도 필요 없음.
  1. 나의 의문점 두 가지
    1. 만약 apps/helper-cook과 packages/ui가 같이 변경이 됐다고 치자. 그럼 paciages/ui를 사용하고 있는 apps/helper-fry에서 에러가 나는가 ?
      • CI (빌드) 단계에서 잡아낼 수 있다. 모노레포를 사용하는 이유 중 하나. 공통 코드를 수정했을 때 영향 받는 모든 앱을 배포 전 단계에서 미리 검증할 수 있다.
    • 그래서 push 전에 로컬에서 CI 단계를 미리 수행하는 단계가 필요하다.
      1. husky & lint-staged 활용: eslint, prettier, tsc -b

      2. husky 활용, git push 할 때 (pre-push) 자동으로 검사 스크립트가 돌아가게 설정하기.
        ```bash
        #!/bin/sh
        . "$(dirname "$0")/_/husky.sh"

         echo "🚀 Push 전 영향 받는 프로젝트 검증 시작..."
         
         # 1. 빌드 및 타입 체크 (영향 받는 모든 프로젝트)
         pnpm turbo build --filter=...[origin/main]
         
         # 2. 린트 체크
         pnpm turbo lint --filter=...[origin/main]
         ```
        
    1. apps/helper-fry의 코드가 변경되지 않았더라도, apps/helper-fry에서 packages/ui를 사용하기 때문에 turborepo가 apps/helper-fry도 같이 변경해야 한다고 알려주는가?
      1. 그렇다.
        • pnpm turbo build --filter=...[origin/main] : **"변경된 것과, 그것에 의존하는 모든 것"**을 찾아냅니다.
          • packages/ui가 변경됨.
          • helper-fry는 코드가 안 바뀌었지만, packages/ui에 의존함.
          • 결과: Turborepo는 helper-fry빌드 대상에 포함시킵니다. (UI가 바뀌었으니 앱도 다시 빌드해서 새 UI를 반영해야 하기 때문)

시나리오 테스트 하기

환경 정리

apps/project-a
apps/project-b

packages/eslint-config
packages/prettier-config
packages/react-hooks
packages/typescript-config
packages/ui

시나리오 1. feature 개발 -> develop에 배포 (production 서버 배포 X, develop 서버 배포 O) 했을 때, 관련된 것만 빌드하기

시나리오 2. develop에 업로드 된 커밋을 main 브랜치에 올려서 production 서버에 배포하기

현재 프로젝트 환경을 main, develop 브랜치로 나누어 관리하고 있기 때문에, 비교 기준을 분리해야 함.

  • develop 브랜치에서: "마지막으로 성공적으로 develop에 배포된 시점" 이후의 변경분만 배포해야 합니다.
  • main 브랜치에서: "마지막으로 성공적으로 main에 배포된 시점" 이후의 변경분(주로 develop에서 넘어온 것들)만 배포해야 합니다.

테스트 환경 설정

  • pnpm-workspace.yaml과 각 앱의 package.json에서 의존성이 잘 설정되어 있어야 함.

    • pnpm-workspace.yaml

      packages:
        - "apps/*"
        - "packages/*"
    • package의 package.json 의 name을 확인하고, 사용하는 쪽의 package.json의 dependency에 포함되어 있는지 확인

      {
        "name": "project-a",
        "dependencies": {
          "@test/ui": "workspace:*" // workspace 프로토콜을 사용하면 로컬 패키지를 바라봅니다.
        }
      }

Github Actions 워크플로우 구성

.github/workflows/deploy.yml

  1. trigger 확인
  • trigger는 PR closed 상황으로 제한함.

    • develop에 직접 push 했을 때 배포되는 상황을 막기 위함.

      • develop에 직접 push 하는 상황을 최대한 안 만들고, 이를 실수라고 여긴다는 전제.
    • main에 hotfix 등으로 반영된 상황이 있고, 이를 develop에 반영할 때 개발 서버가 배포되는 상황 방지

  • 원격으로 수동 실행하는 경우가 있기 때문에 workflow_dispatch 옵션 추가

on:
  pull_request:
    types: [closed]
    branches:
      - main
      - develop
  workflow_dispatch: # 수동 실행 버튼 추가
  1. 변경사항 감지 및 빌드

    • 이 단계에서 "영향을 받는 패키지를 확인하고, 해당 패키지를 빌드하는 과정"을 수행합니다.

      1. 조건부 실행

        if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
        • workflow_dispatch 즉 수동 배포를 실행했거나,

        • 실제로 코드가 타겟 브랜치에 병합되었을 때만 로직을 실행합니다.

      2. 비교 기준점 설정

        if [[ "${{ github.event_name }}" == "pull_request" ]]; then
          echo "BASE=HEAD^1" >> $GITHUB_ENV
        • Squash Merge를 사용하는 전제 하에, 병합이 완료된 직후입니다. 따라서 **현재 내 위치(HEAD)**는 모든 기능이 합쳐진 커밋이고, **HEAD^1**은 합쳐지기 바로 직전의 타겟 브랜치 상태입니다.

        • 이 둘을 비교하여 이번 PR을 통해 들어온 모든 수정사항을 추출합니다.

      3. 빌드

        run: pnpm turbo build --filter=...[${{ env.BASE }}]
        • --filter: 전체 프로젝트 중 이번 수정 사항에 영향을 받는 프로젝트만 골라냅니다.
        • ...[${{ env.BASE }}] : BASE 이후로 바뀐 소스 코드를 찾습니다.
          • [...] 대괄호 안의 기준점(커밋 SHA, 브랜치명 등)과 현재 상태를 비교합니다.
          • ...(Tribble Dots): 그 소스를 사용하는 하위 패키지(ex. packages/ui 등)가 있다면, 그 패키지에 의존하는 상위 앱들(project-a, project-b)까지 빌드 대상으로 포함합니다.
        • 결과적으로 수정되지 않은 프로젝트를 건드리지 않고, 꼭 필요한 것만 빌드합니다.
      4. 배포 대상 전달

        • 이제 어떤 패키지가 빌드 대상에 포함되었는지 다음 Job로 넘겨야 합니다. 그래야 어떤 패키지를 배포해야 할지 판별하기 때문입니다.

          PLAN=$(pnpm turbo build --filter=...[${{ env.BASE }}] --dry=json)
        • 해당 코드를 통해, pnpm turbo build --filter=...[${{env.BASE}}] --dry=json에 대한 JSON 형식의 결과가 PLAN 변수에 담깁니다. 이 결과를 통해 어떤

시나리오 1: Feature 개발 -> 개발 환경 배포

  1. 로컬 작업: apps/project-a의 코드를 수정하거나, packages/ui를 수정합니다.
  2. Push 전 검증: 로컬에서 huskypnpm turbo build --filter=...[origin/main]를 실행하여 project-a가 깨지지 않는지 확인합니다.
  3. develop 브랜치 Push:
    • GitHub Actions가 실행됩니다.
    • build-and-test Job에서 project-a만 빌드됩니다. (캐싱 활용)
    • deploy-dev Job이 실행되지만, project-b는 변경사항이 없으므로 스킵되고 project-a만 배포됩니다.
    • 결과: main 브랜치는 영향이 없고, develop 서버만 업데이트됩니다.

내가 한 실제 행동

  1. packages/ui 코드 수정

  2. pnpm turbo build --filter=...[origin/main] 실행

    1. 결과: image-20251223172141113

    2. 결과 분석

      1. 현상 의미 배포 여부 판단
        Cache Miss 코드가 바뀌어서 새로 빌드함. 배포 필요
        Cache Hit 코드는 예전 어떤 시점과 같음. 그래서 복사본을 꺼내줌. 상황에 따라 다름
      2. 공통 패키지 UI만 변경했을 때,

        1. 사용하지 않는 react-hooks -> cache hit -> 빌드 안 함
        2. 사용하는 project-a, project-b, 그리고 packages/ui -> cache miss -> 새로 빌드 함
  3. project-a 코드만 수정했을 때는 다른 프로젝트는 변경 사항이 없으므로, 모두 cache hit이 나야 함 -> 확인

  4. project-a만 수정한 경우 -> project-a 만 서버 배포되는 로직 실행, project-b는 실행 안됨 확인

  5. project-b만 수정한 경우 -> project-b만 서버 배포되는 로직 실행, project-a는 실행 안 됨 확인

  6. ui만 수정한 경우 -> project-a, project-b 둘 다 서버 배포되는 로직 실행 확인

시나리오 2: 개발 환경 -> 운영 환경 배포

개발 환경에서 운영 환경으로 배포할 때의 흐름

  1. develop 브랜치에서 main 브랜치를 타겟으로 하는 PR을 연다.

트러블슈팅 1. 직전 커밋이 Merge ... 인 경우, 변경 사항을 감지하지 않고 아무 프로젝트도 배포하지 않음.

원인: BASE=HEAD^1의 설정

  1. 상황: develop에서 기능을 다 만들고 main으로 합쳤습니다. (예: 커밋 5개가 합쳐짐)
  2. 현재 로직: main에 코드가 들어오자마자 **직전 커밋(HEAD^1)**과 비교합니다.
  3. 결과: 만약 main 브랜치의 마지막 커밋이 "Merge pull request..." 같은 머지 커밋이라면, 터보는 그 머지 커밋 하나만 보고 "어? 바뀐 게 별로 없네?"라고 판단하거나, 반대로 예상치 못한 기준으로 빌드 범위를 잡습니다.

즉, 운영 배포(main)는 '직전 커밋'이 아니라 '마지막으로 성공했던 운영 배포 시점'과 비교해야 합니다.

- name: Determine Base Ref
    id: set-base
    run: |
      if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
        # main 배포 시: github.event.before(이전 상태)를 가져옴
        BASE_REF="${{ github.event.before }}"

        # 만약 첫 배포라 'before'가 0000... 이라면 안전하게 HEAD^1 사용
        if [ "$BASE_REF" = "0000000000000000000000000000000000000000" ] || [ -z "$BASE_REF" ]; then
          BASE_REF="HEAD^1"
        fi
        echo "BASE=$BASE_REF" >> $GITHUB_ENV
      else
        # develop 배포 시: 항상 직전 커밋과 비교
        echo "BASE=HEAD^1" >> $GITHUB_ENV
      fi
  1. BASE_REF="${{ github.event.before }}"

    • 현재 push가 일어나기 직전의 최신 커밋 해시(ID), "비교의 시작점"으로 사용함.
      • 반드시 squash merge를 해야 의미 있게 됨?
  2. 0000... / -z 의미

  • 0000...: 브랜치를 새로 만들고 처음으로 push 했을 때 Github가 보내는 값. "이전 기록이 전혀 없다"는 뜻.
  • -z "$BASE_REF": 변수가 비어있는 경우를 대비한 안전장치.